Visão Geral
Contexto, atores, arquitetura e estado atual da integração SIMPE × SAGE/MGDI.
O problema
O SIMPE precisa exibir indicadores oficiais (números de equipes de saúde, valores repassados, cobertura, etc.) dentro do plano estratégico. Esses dados já existem na SAGE. Em vez de duplicar a coleta, integramos via API do MGDI.
Escopo do piloto
16
Cards do mockup
7
Indicadores cobertos
~44%
Cobertura efetiva
35/35
Endpoints OK em 27/04
2
PMM · prontos pra renderizar
Saúde Bucal, 16 cards de indicadores nos mockups. Cobertura técnica atual: 7 indicadores com endpoint funcional. O restante depende de mapeamento ou novas fontes a confirmar com o DEMAS. A expansão para outras secretarias já começou: em 26/05/2026 chegaram 5 códigos novos de Saúde Bucal (CEO Tipo I/II/III, eSB Modalidade I/II) — registrados, mas ainda sem dado (carga/resultset retornam 500 ou vazio). E o SGTES (Mais Médicos) entrou em dois lotes: 09/06/2026 com 2 códigos MN prontos pra renderizar, e 22/06/2026 com 13 códigos que cobrem vagas ativas, vagas em processo de ocupação (BR/UF/DSEI) e as residências PRORES/PRAPS (anuais) — 12 com dado real até 2026; só MGDI_MS_XAW segue quebrado (HTTP 500). Detalhes em 🗺️ Cobertura e 🩺 SGTES.
Arquitetura
A API MGDI só resolve dentro da VPN interna do MS, e Vercel/Supabase não estão na VPN. Um teste real mostrou que o APISIX devolve CORS aberto (ecoa o Origin + Vary: Origin). Logo, o fetch roda no navegador do administrador (que está na VPN) e os dados parseados são persistidos no Postgres via server actions com service_role. Não há túnel cloud→VPN, nem edge function, nem cron. Depois de persistido, todas as leituras do SIMPE são rápidas e independem da VPN. Fluxo completo em 💻 Implementação.
Stack do SIMPE
- Frontend: Next.js 16 (App Router) + React 19 + TypeScript
- Backend: server actions + camada
rpc/(lógica de banco) - Banco: Supabase (Postgres) — dados SAGE persistidos em 4 tabelas + 1 view (ver 🗄️ Schema)
- Sync: client-side no navegador do admin (na VPN) → server actions → upsert via
service_role
A camada de backend/ingestão já está implementada: migration 20260608150050_create_indicadores_sage_schema.sql (4 tabelas + view), client MGDI (lib/sage/mgdi-client.ts), server actions/RPCs de sync e vínculo, e a aba Painel Admin → Indicadores SAGE. Os testes contra a rede interna do MS (27/04/2026) validaram 35/35 endpoints OK e identificaram 3 ressalvas tratadas no front: ano corrente parcial nos financeiros, agrupamento no resultset MN (resolvido por view), e inconsistência titulo × tituloCompleto. Detalhes em ⚠️ Ressalvas. O que falta é a UI dos cards — estratégia em 🎨 Estratégia de Frontend.
Cobertura
Cruzamento entre os 16 cards dos mockups e os endpoints disponíveis na API MGDI.
Cola rápida — o que cada endpoint retorna
São 5 padrões de URL. Cada um responde uma pergunta diferente sobre o indicador (independente do código):
| Endpoint | Pergunta que responde | O que retorna | Tamanho típico | Usa pra |
|---|---|---|---|---|
GET /indicador/{codigo}metadado |
"Quem é esse indicador?" | Objeto com titulo, tituloCompleto, descricao, fonte_dados, cache_ttl, unidade de medida, polaridade (maior=melhor ou menor=melhor), granularidade (BR/UF/MN), tags, responsáveis (técnico/gerencial) e visualização sugerida. |
~25 KB | Título do card e ficha (i) |
GET /indicador/{codigo}/cargasincronismo |
"Pra que períodos existe dado?" | Array enxuto de { co_anomes: YYYYMM } listando todas as competências carregadas. Funciona como índice de sincronismo — antes de pedir o resultset você sabe se há dado novo. É aqui que buracos aparecem (ex.: CEOVLR pula 202410). |
~850 B | Decidir invalidação de cache |
GET /indicador/{codigo}/resultset?tipo=BR&anodata=-5evolução Brasil |
"Como o número evoluiu nos últimos 5 anos no país inteiro?" | Array de { anomes, <CODIGO>: valor }. O nome do campo do valor é o próprio código do indicador (ex.: CEOIMP, CEOVLR). Não-financeiros vêm mensais; financeiros vêm com poucos pontos anuais + ano corrente parcial. |
~1.2 KB (não-fin.) · ~150–200 B (fin.) | Gráfico de evolução do card |
GET /indicador/{codigo}/resultset?tipo=MN&anodata=-1mapa por município |
"Como o número se distribui nos ~5500 municípios no último ano?" | Array com { anomes, uf, regiao, local, codigogeo, <CODIGO>: valor }. codigogeo é o IBGE de 6 dígitos sem dígito verificador (casa direto com GeoJSON). Pegadinha: o mesmo município pode aparecer em duas competências — agrupar por codigogeo mantendo o anomes máximo antes de renderizar. |
500 KB – 1.1 MB | Mapa coroplético do card |
GET /ficha-qualificacao/{codigo}ficha descritiva |
"Onde está o documento explicativo?" | Documento (HTML/JSON) com objetivo, conceituação, interpretação, fonte, limitações e notas técnicas — derivado dos mesmos campos do metadado, formatado como texto pronto pra exibir. | ~3 KB | Ícone (i) do card |
GET /indicador?limit=N&ativos=truebônus — listagem |
"Que indicadores existem no catálogo?" | Envelope { count, rows } com versão reduzida do metadado de cada um (sem blob HTML). Catálogo total: 1002 indicadores. |
variável | Descoberta/busca — não pra renderizar card |
Resumo mental: metadado = "quem é", carga = "quando tem", resultset BR = "linha do tempo nacional", resultset MN = "mapa", ficha = "documento". Os 4 primeiros bastam pra montar o card; a ficha é o (i).
4 elementos de cada card
| Requisito | Endpoint | Exemplo de retorno | Validado |
|---|---|---|---|
| (1) Título descritivo | GET /indicador/{codigo} → tituloCompleto | metadado-ceoimp.json · metadado-ceovlr.json | ✓ |
| (2) Evolução BR (5 anos) | GET /indicador/{codigo}/resultset?tipo=BR&anodata=-5 | resultset-br-ceoimp.json · resultset-br-ceovlr.json | ✓ |
| (3) Mapa por município | GET /indicador/{codigo}/resultset?tipo=MN&anodata=-1 | resultset-mn-trecho.json | ✓ |
| (4) Ficha (i) | GET /ficha-qualificacao/{codigo} | ficha-qualificacao-ceoimp.html | ✓ |
| Auxiliar (sync) | GET /indicador/{codigo}/carga | carga-anual.json · carga-financeiro.json | ✓ |
Fonte: _source/01-cobertura-api-simpe-saude-bucal.md §1
Cards × código MGDI × status
| Mockup | Card | Código MGDI | Endpoints & samples | Status | Observação |
|---|---|---|---|---|---|
| 4.1 | CEO cofinanciados | CEOIMP | 5 endpoints
| ✅ coberto | Série anual limpa, mapa ~1.1 MB |
| 4.1 | Valor repassado CEO | CEOVLR | 5 endpoints
| ⚠️ ano parcial | Inclui 202602 misturado com anos fechados |
| 4.2 | Novas UOM entregues | — | — | ❌ sem código | Pode ser derivado de SBUOMP — DEMAS |
| 4.2 | UOM cofinanciadas | SBUOMP | 5 endpoints
| ✅ coberto | Série anual limpa |
| 4.2 | Valor repassado UOM | SBRCUOM | 5 endpoints
| ⚠️ ano parcial | Inclui 202602 parcial |
| 4.3 | Equipes Saúde Bucal 40h | SBESB40H | 5 endpoints
| ✅ coberto | Série anual limpa |
| 4.3 | Carga Horária Diferenciada | SBESB - SBESB40H | derivado: 2× 5 endpoints
| ⚠️ derivado | 20h + 30h. Validar definição com Mariana |
| 4.4 | eSB cofinanciadas | ? | — | ⚠️ ambíguo | Pode ser SBESB com outra leitura |
| 4.4 | Valor repassado equipes | SBVRFAF | 5 endpoints
| ⚠️ ano parcial | Inclui 202602 parcial |
| 4.5 | Sesb cofinanciadas | — | — | ❌ sem código | — |
| 4.5 | Equipamentos odontológicos | — | — | ❌ sem código | — |
| 4.5 | Impressoras 3D e scanners | — | — | ❌ sem código | — |
| 4.5 | Profissionais qualificados | — | — | ❌ sem código | — |
| 4.5 | Municípios qualificados | — | — | ❌ sem código | — |
| 4.5 | Mun. c/ LRPD cofinanciados | — | — | ❌ sem código | — |
| 4.6 | Curso de gerentes CEO/SESB | — | — | ❌ sem código | Fora do piloto até definição |
| 4.6 | Oferta técnica em saúde bucal | — | — | ❌ sem código | — |
| 4.6 | Oferta pós-técnica saúde bucal | — | — | ❌ sem código | — |
Fonte: _source/01-cobertura-api-simpe-saude-bucal.md §2
Resumo quantitativo
16
Totais
6
Cobertos diretos
1
Derivado
1
Ambíguo
8
Sem código
~44%
Cobertura efetiva
A API entrega 100% dos elementos visuais exigidos (título, evolução, mapa, ficha) para os indicadores que ela cobre. O gap é de cobertura de indicadores, não de funcionalidade da API.
Novos códigos SAPS (lote 26/05/2026) — dados pendentes
Chegaram 5 códigos novos de Saúde Bucal. Probe real em 26/05/2026 (_source/reports/novos-codigos-report.csv): metadado e ficha respondem 200 em todos, mas /carga e /resultset ou retornam HTTP 500 ou array vazio. Ou seja, ainda não dá pra renderizar evolução nem mapa — confirmam o flag "ENCONTRAR O DADO" do CSV. Ficam fora do piloto até o DEMAS carregar os dados.
Dois códigos vinham errados no novos-cods.csv e davam 404: SBCEOTIPOII → o real é SBCEOTIPII; SBCEOTIPOIII → o real é SBCEOTPIII. Além disso o CSV rotula as duas linhas como "Tipo III" (a primeira é Tipo II).
| Indicador | Código MGDI | Endpoints & samples | Status | Observação |
|---|---|---|---|---|
| CEO Tipo I | SBCEOTIPO1 | 5 endpoints
| ❌ erro 500 | carga/resultset → HTTP 500 |
| CEO Tipo II | SBCEOTIPII | 5 endpoints
| ❌ erro 500 | CSV trazia SBCEOTIPOII (404). carga/resultset → 500 |
| CEO Tipo III | SBCEOTPIII | 5 endpoints
| ⚠️ sem dado | CSV trazia SBCEOTIPOIII (404). carga/resultset → [] |
| eSB Modalidade I | SBESBMODI | 5 endpoints
| ⚠️ sem dado | carga/resultset → []. Pode ser a leitura de "eSB cofinanciadas" (4.4) |
| eSB Modalidade II | SBESBMODII | 5 endpoints
| ❌ erro 500 | carga/resultset → HTTP 500 |
Fonte: _source/05-novos-codigos-2026-05.md · _source/reports/novos-codigos-report.csv
SGTES — Mais Médicos
Primeira secretaria além da Saúde Bucal. Dois lotes: 09/06/2026 (PMM, lote PPF*) e 22/06/2026 (vagas por granularidade + residências). Todos testados na VPN.
A área enviou PPFVAPMM e PPFTVA para o Programa Mais Médicos. Probe real na VPN em 09/06/2026: os dois respondem 200 nos 5 endpoints, com dado real. Seguem exatamente o mesmo contrato do piloto Saúde Bucal — granularidade MN (município), carga mensal co_anomes, codigogeo IBGE de 6 dígitos, campo de valor = o próprio código. Logo, renderizam card completo (título + evolução BR + mapa MN + ficha) com o pipeline que já existe, sem tratamento especial. São os primeiros indicadores SGTES prontos pra renderizar.
Indicadores
| Indicador (título API) | Código | Granul. | Carga | Status | Observação |
|---|---|---|---|---|---|
| Total de vagas ativas do Programa Mais Médicos | PPFVAPMM | MN | 201612 → 202509 (40 comp.) | ✅ coberto | Só PMM. BR 35 pontos (~15,8 mil vagas); mapa MN ~5,1 MB |
| Total de vagas ativas (PMM + PMpB) | PPFTVA | MN | 201612 → 202605 (48 comp.) | ✅ coberto | Agregado PMM + Programa Médicos pelo Brasil. BR 40 pontos (~27,6 mil vagas); mapa MN ~2,8 MB |
Diferença entre os dois: PPFVAPMM conta apenas as vagas do Programa Mais Médicos; PPFTVA é o agregado que soma PMM + PMpB (Programa Médicos pelo Brasil), os dois programas de provimento federal em vigência.
Endpoints & samples
PPFVAPMM — Total de vagas ativas do PMM
GET /indicador/PPFVAPMM→ metadado-ppfvapmm.json (200, granularidade MN)GET /indicador/PPFVAPMM/carga→ carga-ppfvapmm.json (200 ·co_anomesmensal)GET /indicador/PPFVAPMM/resultset?tipo=BR&anodata=-5→ resultset-br-ppfvapmm.json (200 ·{anomes, PPFVAPMM})GET /indicador/PPFVAPMM/resultset?tipo=MN&anodata=-1→ resultset-mn-ppfvapmm-trecho.json (200 · ~5,1 MB ·codigogeoIBGE)GET /ficha-qualificacao/PPFVAPMM→ ficha-qualificacao-ppfvapmm.json (200)
PPFTVA — Total de vagas ativas (PMM + PMpB)
GET /indicador/PPFTVA→ metadado-ppftva.json (200, granularidade MN)GET /indicador/PPFTVA/carga→ (200 ·co_anomesmensal · 201612 → 202605)GET /indicador/PPFTVA/resultset?tipo=BR&anodata=-5→ resultset-br-ppftva.json (200 ·{anomes, PPFTVA})GET /indicador/PPFTVA/resultset?tipo=MN&anodata=-1→ (200 · ~2,8 MB ·codigogeoIBGE)GET /ficha-qualificacao/PPFTVA→ (200)
(1) Ano corrente parcial e fora de ordem: o resultset BR mistura competências de anos diferentes sem ordenação (PPFTVA começa em 202601–202603, parciais) — ordenar por anomes antes de plotar. (2) Município repetido em várias competências no MN (ex.: Acrelândia em 202501–202509) — agrupar por codigogeo mantendo o anomes máximo, igual ao piloto. (3) PPFVAPMM veio sem responsáveis técnico/gerencial e sem polaridade; PPFTVA traz SAPS. Periodicidade é null em ambos.
Lote 22/06/2026 — vagas por granularidade + residências
Segundo lote do SGTES, vinculado a AE 11.5 RE 11.5.1 (Objetivo Estratégico 11) e ao eixo de residências (AE 1.12). 13 códigos testados na VPN em 22/06/2026.
MGDI_MS_* é uma fatia de granularidade, não um indicador municipalDiferente dos PPF* (que são MN/município), os códigos MGDI_MS_* deste lote declaram uma granularidade fixa cada — BR (Brasil), UF (estadual) ou DS (DSEI indígena). Logo, o /resultset?tipo=MN deles retorna HTTP 500 — e isso é esperado, não é bug: eles simplesmente não têm recorte municipal. Cada um precisa ser consultado na sua granularidade (tipo=BR, tipo=UF ou tipo=DS). Testados assim, os indicadores de vagas têm dado real e atual (até 202606).
anodata= (ano) vs data= (competência)O /resultset aceita dois filtros com semânticas diferentes — confirmado em teste:
anodata=filtra por ano:anodata=2026= todas as competências de 2026;anodata=-2= últimos 2 anos.data=filtra por competência (mês):data=202606= só junho/2026 (1 linha);data=-3= últimas 3 competências.
Regra prática: indicador mensal (carga traz co_anomes) → anodata=-N (janela de anos) ou data=<YYYYMM>/data=-N para precisão mensal. Indicador anual (carga traz co_ano) → iterar anodata=<ano> por ano listado na carga. Atenção: nos anuais a janela negativa (anodata=-5) não funciona (volta []) — só o ano explícito.
Os dois indicadores de vagas ficam totalmente cobertos: somando o agregado municipal (PPF*) com as fatias BR/UF/DSEI (MGDI_MS_*), dá pra renderizar evolução nacional, ranking estadual e recorte indígena até junho/2026. As 4 residências também têm dado — pareciam vazias só por causa do parâmetro (eram anuais chamados com janela mensal). Único ainda quebrado: MGDI_MS_XAW (HTTP 500).
Vagas ativas, por tipo de equipe
| Título (API) | Código | Granul. | Resultset testado | Status |
|---|---|---|---|---|
| Total de vagas ativas (PMM + PMpB) | PPFTVA | MN | BR 40 pts → 202603 · MN ~2,8 MB | ✅ coberto |
| Total de vagas ativas Brasil | MGDI_MS_32F | BR | BR 43 pts · 202212 → 202606 | ✅ coberto |
| Total de vagas ativas Estadual | MGDI_MS_SA4 | UF | UF 270 linhas → 202606 | ✅ coberto |
| Total de vagas ativas DSEI | MGDI_MS_GIZ | DS | DS 340 linhas → 202606 | ✅ coberto |
Vagas em processo de ocupação
| Título (API) | Código | Granul. | Resultset testado | Status |
|---|---|---|---|---|
| Vagas em processo de ocupação | PPFVOCUP | MN | BR 31 pts → 202603 · MN ~3,4 MB | ✅ coberto |
| Vagas em processo de ocupação Brasil | MGDI_MS_XVH | BR | BR 34 pts · 202309 → 202606 | ✅ coberto |
| Vagas em processo de ocupação Estadual | MGDI_MS_39Q | UF | UF 270 linhas → 202606 | ✅ coberto |
| Vagas em processo de ocupação DSEI | MGDI_MS_WFS | DS | DS 340 linhas → 202606 | ✅ coberto |
Residências (PRORES / PRAPS) — eixo AE 1.12
Estes 4 códigos são anuais (granularidade UF, carga com co_ano de 2010 a 2026). No primeiro probe voltaram [] porque foram chamados com anodata=-5 (janela mensal), que não funciona em série anual. Passando o ano explícito (?tipo=UF&anodata=2025), retornam as 27 UFs com dado. Estrutura da linha: {ano, uf, regiao, codigogeo, <código>}.
| Indicador (CSV) | Código | Granul. | Chamada correta | Status |
|---|---|---|---|---|
| Nº de profissionais da saúde que ingressaram no PRORES (bolsas MS) | MGDI_MS_3KP | UF (anual) | ?tipo=UF&anodata=<ano> → 27 UFs | ✅ coberto |
| Total de residentes ativos no PRAPS (bolsas MS), por ano | MGDI_MS_4RU | UF (anual) | ?tipo=UF&anodata=<ano> → 27 UFs | ✅ coberto |
| Nº de profissionais da saúde no PRAPS (bolsas MS) | MGDI_MS_543 | UF (anual) | ?tipo=UF&anodata=<ano> → 27 UFs | ✅ coberto |
| Nº de residentes médicos que ingressaram (bolsas MS), por ano | MGDI_MS_XLJ | UF (anual) | ?tipo=UF&anodata=<ano> → 27 UFs | ✅ coberto |
MGDI_MS_XAWÚnico do lote sem dado utilizável. Responde 200 em /indicador e /ficha-qualificacao, mas falha já no /carga com HTTP 500 (syntax error at or near "null", Postgres 42601) — e o erro persiste com qualquer parâmetro de data (anodata=2025, data=202505…). É o mesmo bug de backend que derruba 3 códigos de Saúde Bucal do lote 26/05, então não é caso de parâmetro. Bloqueia o indicador "Vagas ativas, por status" (cujo único código é o XAW). Pendência com o DEMAS.
| Indicador (CSV) | Código | Granul. | Problema | Status |
|---|---|---|---|---|
| Vagas ativas, por status (ocupadas/em ocupação/desocupadas/inativas) — PMM | MGDI_MS_XAW | BR | /carga e /resultset → HTTP 500 em qualquer parâmetro | ❌ erro 500 |
Fonte: lote 22/06/2026 · _source/reports/codigos-22-06-report.csv · _source/reports/problema-indicadores-22-06.csv
Resumo SGTES
14
Códigos (2 lotes)
13
Com dado real
1
Quebrado (XAW · 500)
7
Indicadores cobertos
Cobertos: PMM (vagas ativas) via PPFVAPMM; vagas ativas por tipo de equipe e vagas em processo de ocupação, cada um com agregado MN + fatias BR/UF/DSEI; e as 4 residências PRORES/PRAPS (anuais, via anodata=<ano>). PPFTVA aparece nos dois lotes. Único bloqueado: vagas por status — MGDI_MS_XAW dá HTTP 500 (bug de backend).
Fonte: PPF* (09/06/2026) · lote 22/06/2026 · _source/reports/novos-codigos-report.csv · codigos-22-06-report.csv · problema-indicadores-22-06.csv
Endpoints
Catálogo dos 5 tipos de endpoint usados no piloto. Base: http://apisix.demas.saude.gov/api-dev (rede interna do MS).
Tipos de chamada
Os 7 indicadores cobertos (CEOIMP, CEOVLR, SBESB, SBESB40H, SBRCUOM, SBUOMP, SBVRFAF) usam os mesmos 5 padrões de URL. Tamanhos e tempos de resposta vêm dos testes reais executados em 27/04/2026 (_source/reports/saps-bucal-report.csv).
1. GET /indicador/{codigo}
Metadados completos do indicador: títulos, descrição, fórmula, fonte, periodicidade, unidade de medida, polaridade, responsáveis e indicadores relacionados.
// Trecho representativo de _source/samples/metadado-ceoimp.json
{
"id": 1185,
"codigo": "CEOIMP",
"titulo": "Número de Centros de Especialidades Odontológicas implantado e custeado",
"tituloCompleto": "Número de Centros de Especialidades Odontológicas pago no período",
"descricao": "Quantidade de CEO - Centro de Especialidades Odontológicas, tipos I, II e III...",
"fonte_dados": "<p>SISAB</p>",
"ativo": true,
"acumulativo": false,
"cache_ttl": 43200,
"co_unidade_medida": 2,
"Tags": [
{ "codigo": 120, "descricao": "Política Nacional de Saúde Bucal (PNSB)",
"TagCategoria": { "codigo": 1, "descricao": "Ações e Programas" } }
],
"UnidadeMedida": { "codigo": 2, "descricao": "Número", "fl_fator": 1 },
"Polaridade": { "codigo": 1, "descricao": "Maior - melhor" },
"Granularidade": { "codigo": 4, "sigla": "MN", "descricao": "Município" },
"ParametroFonte": null,
"ResponsavelTecnico": [{ "codigo": 17494, "sigla": "CGSB", "nome": "COORDENAÇÃO-GERAL DE SAÚDE BUCAL" }],
"ResponsavelGerencial": [{ "codigo": 19174, "sigla": "SAPS", "nome": "SECRETARIA DE ATENÇÃO PRIMÁRIA À SAÚDE" }],
"VisualizacaoIndicador": [{ "codigo": 5, "nome": "Gráfico de linha" }]
}titulo × tituloCompletoNão seguem padrão consistente entre indicadores. Recomenda-se dicionário externo. Detalhes em Ressalva #3.
2. GET /indicador/{codigo}/carga
Lista de competências disponíveis (sincronismo). Retorna array de { co_anomes } no formato YYYYMM.
// Trecho de _source/samples/carga-financeiro.json (CEOVLR)
[
{ "co_anomes": 201612 },
{ "co_anomes": 201712 },
{ "co_anomes": 201812 },
{ "co_anomes": 202112 },
{ "co_anomes": 202212 },
{ "co_anomes": 202301 },
{ "co_anomes": 202302 },
... // mensal a partir de 202301
{ "co_anomes": 202408 },
{ "co_anomes": 202409 },
// ⚠️ falta 202410 — pendência aberta com DEMAS
{ "co_anomes": 202411 },
{ "co_anomes": 202412 },
...
{ "co_anomes": 202601 },
{ "co_anomes": 202602 }
]O CEOVLR pula a competência 202410 (vai de 202409 direto para 202411). Pode ser indisponibilidade real ou falha de carga. Pendência aberta — ver ✅ Pendências.
3. GET /indicador/{codigo}/resultset?tipo=BR&anodata=-5
Evolução agregada Brasil. Retorna array com anomes + campo de valor cujo nome é o próprio código do indicador (ex: CEOIMP).
// Trecho de _source/samples/resultset-br-ceoimp.json — não-financeiro, mensal
[
{ "anomes": 202601, "CEOIMP": 1154 },
{ "anomes": 202602, "CEOIMP": 1175 },
{ "anomes": 202501, "CEOIMP": 1209 },
{ "anomes": 202502, "CEOIMP": 1209 },
{ "anomes": 202503, "CEOIMP": 1210 },
{ "anomes": 202504, "CEOIMP": 1210 },
{ "anomes": 202505, "CEOIMP": 1211 },
// ... 39 pontos mensais cobrindo 5 anos calendário
{ "anomes": 202412, "CEOIMP": 1209 },
{ "anomes": 202312, "CEOIMP": 1192 },
{ "anomes": 202212, "CEOIMP": 1158 }
]Nos indicadores CEOVLR, SBRCUOM e SBVRFAF a série mistura anos fechados (dezembro) com o ano corrente parcial. Detalhes em Ressalva #1.
// _source/samples/resultset-br-ceovlr.json — financeiro com ano parcial visível
[
{ "anomes": 202602, "CEOVLR": 89421018.12 }, // PARCIAL: jan+fev/2026
{ "anomes": 202512, "CEOVLR": 506377601.91 }, // 2025 fechado
{ "anomes": 202412, "CEOVLR": 502460676.17 }, // 2024 fechado
{ "anomes": 202312, "CEOVLR": 282328339.49 }, // 2023 fechado
{ "anomes": 202212, "CEOVLR": 226805471.18 } // 2022 fechado
]4. GET /indicador/{codigo}/resultset?tipo=MN&anodata=-1
Distribuição por município (mapa). codigogeo é o código IBGE de 6 dígitos sem dígito verificador — casa com qualquer GeoJSON do Brasil. Campos uf, regiao e local já vêm preenchidos.
// Trecho de _source/samples/resultset-mn-trecho.json (CEOIMP)
[
{ "anomes": 202601, "uf": "AC", "regiao": "Norte", "local": "Acrelândia", "codigogeo": 120001, "CEOIMP": 0 },
{ "anomes": 202602, "uf": "AC", "regiao": "Norte", "local": "Acrelândia", "codigogeo": 120001, "CEOIMP": 0 },
{ "anomes": 202601, "uf": "AC", "regiao": "Norte", "local": "Assis Brasil","codigogeo": 120005, "CEOIMP": 0 },
{ "anomes": 202602, "uf": "AC", "regiao": "Norte", "local": "Assis Brasil","codigogeo": 120005, "CEOIMP": 0 },
// ... ~5500 municípios, cada um com múltiplas competências do ano corrente
]Acrelândia (codigogeo 120001) aparece duas vezes: anomes 202601 e 202602. É obrigatório agrupar por codigogeo mantendo o item com anomes máximo antes de renderizar — senão o mapa renderiza com valor errado de janeiro. Detalhes em Ressalva #2.
5. GET /ficha-qualificacao/{codigo}
Ficha de qualificação do indicador (formato HTML/JSON). Usada no ícone (i) ao lado do título do card.
Conteúdo: texto descritivo (objetivo, conceituação, interpretação, fonte, limitações, notas) — derivado dos mesmos campos do metadado mas formatado como documento. Ver _source/samples/ficha-qualificacao-ceoimp.html.
Bônus — listagem paginada
GET /indicador?limit=N&ativos=true
Listagem paginada de indicadores. Útil para descoberta. Envelope: { count, rows }.
// Trecho de _source/samples/listagem-paginada.json (limit=3)
{
"count": 1002,
"rows": [
{
"id": 2290,
"codigo": "AIDSNCN",
"titulo": "Número de casos de aids identificados no período",
"tituloCompleto": "Número de casos de aids identificados no período",
"ativo": true,
"UnidadeMedida": { "codigo": 2, "descricao": "Número" },
"Granularidade": { "codigo": 4, "descricao": "Município" },
"ResponsavelTecnico": [{ "codigo": 7060, "sigla": "SVSA", "nome": "SECRETARIA DE VIGILÂNCIA EM SAÚDE E AMBIENTE" }]
}
]
}Tabela de testes (27/04/2026)
| Indicador | Metadado | Carga | Resultset BR | Resultset MN | Ficha |
|---|---|---|---|---|---|
CEOIMP | 31 KB / 0.40 s | 904 B / 0.18 s | 1.2 KB / 0.97 s | 1.1 MB / 5.07 s | 2.9 KB / 0.24 s |
CEOVLR | 31 KB / 0.30 s | 904 B / 0.26 s | 200 B / 0.77 s | 562 KB / 1.91 s | 3.9 KB / 0.18 s |
SBESB | 31 KB / 0.30 s | 862 B / 0.26 s | 1.2 KB / 0.19 s | 1.1 MB / 3.96 s | 3.9 KB / 0.21 s |
SBESB40H | 21 KB / 0.30 s | 946 B / 0.18 s | 1.4 KB / 0.81 s | 1.1 MB / 3.33 s | 3.9 KB / 0.49 s |
SBRCUOM | 18 KB / 0.30 s | 799 B / 0.19 s | 147 B / 0.73 s | 563 KB / 1.56 s | 4.3 KB / 0.19 s |
SBUOMP | 6 KB / 0.24 s | 799 B / 0.18 s | 1.2 KB / 0.81 s | 1.1 MB / 3.20 s | 2.4 KB / 0.19 s |
SBVRFAF | 30 KB / 0.33 s | 946 B / 0.18 s | 197 B / 0.82 s | 583 KB / 1.57 s | 3.2 KB / 0.51 s |
Fonte: _source/reports/saps-bucal-report.csv
Schema do Banco
Schema implementado na migration 20260608150050_create_indicadores_sage_schema.sql — catálogo, dados observados (série nacional + mapa municipal) e o vínculo com os itens estratégicos. Incorpora os indicadores SAGE/MGDI ao SIMPE em 4 tabelas + 1 view.
As 4 tabelas + a view existem no Postgres e RLS restrita a Admin/SuperAdmin. Os exemplos de linha abaixo refletem o schema real.
- Persistir tudo no Postgres — a API MGDI só responde na VPN interna do MS; leitura ao vivo quebraria fora da VPN.
- Vínculo N:N indicador↔item (tabela de junção), degrada para 1:1 naturalmente.
- Mapa municipal com grão histórico completo
(codigo, anomes, codigogeo); "mapa atual" = view. - Schema genérico + dimensão de origem (secretaria/tema/fonte); popular só os 7 do piloto Saúde Bucal.
Visão geral — 4 tabelas + 1 view
┌───────────────────────────┐
│ indicadores_sage │ catálogo (1 linha/indicador)
│ PK codigo ('CEOIMP'...) │
└────────────┬──────────────┘
┌─────────────────────┼───────────────────────┐
│ │ │
┌──────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────┐
│ indicador_sage_ │ │ indicador_sage_valor_ │ │ item_indicador_sage │
│ serie_br │ │ municipio │ │ (junção N:N) │
│ PK (codigo, anomes) │ │ PK (codigo,anomes, │ │ PK id │
│ → série nacional │ │ codigogeo) │ │ UQ (item_id, codigo) │
│ (≈5 linhas) │ │ → mapa, histórico │ │ │ │
└──────────────────────┘ │ (≈5500×comp×ind) │ │ ▼ │
└────────────┬─────────────┘ │ itens_estrategicos │
│ │ (tabela existente) │
┌────────────▼─────────────┐ └──────────────────────┘
│ vw_indicador_sage_ │
│ mapa_atual (view) │ max(anomes) por município
└──────────────────────────┘Mapa: endpoint MGDI → onde mora no schema
| Endpoint | Destino no schema |
|---|---|
GET /indicador/{codigo} (metadado) | indicadores_sage · titulo, titulo_completo, unidade, metadado_raw |
GET /indicador/{codigo}/carga | indicadores_sage.competencias_disponiveis |
GET /indicador/{codigo}/resultset?tipo=BR | indicador_sage_serie_br |
GET /indicador/{codigo}/resultset?tipo=MN | indicador_sage_valor_municipio (+ view do mapa atual) |
GET /ficha-qualificacao/{codigo} | indicadores_sage.ficha |
1. indicadores_sage — catálogo
Uma linha por indicador MGDI. Espelha o metadado + a ficha + a dimensão de origem. Os campos titulo/titulo_completo da API são inconsistentes entre indicadores — os títulos de card vêm de um dicionário no front, não daqui.
| Campo | Tipo | Significado |
|---|---|---|
codigo | text PK | Código MGDI único (ex. CEOIMP). FK em todas as outras tabelas. |
titulo / titulo_completo | text | Títulos crus da API. Não usar direto na UI. |
unidade | text | 'Número' ou 'Moeda'. Dirige formatação (R$) e tratamento de ano parcial. |
periodicidade_carga | text | 'mensal' (financeiros) ou 'anual'. |
fonte / secretaria / tema | text | Dimensão de origem: 'SAGE/MGDI' · 'SAPS' · 'Saúde Bucal'. |
ficha | jsonb | Payload de /ficha-qualificacao. |
metadado_raw | jsonb | Metadado completo (fidelidade / campos futuros sem migration). |
competencias_disponiveis | jsonb | Competências de /carga; o sync usa para detectar nova carga. |
ativo | boolean | Soft-disable (sem hard delete). |
*_synced_at | timestamptz | Freshness por dataset (metadado / série BR / município / ficha). |
// Exemplo de linha (CEOIMP)
{
"codigo": "CEOIMP",
"titulo": "Número de Centros de Especialidades Odontológicas implantado e custeado",
"titulo_completo": "Número de Centros de Especialidades Odontológicas pago no período",
"unidade": "Número",
"periodicidade_carga": "anual",
"fonte": "SAGE/MGDI",
"secretaria": "SAPS",
"tema": "Saúde Bucal",
"ficha": { "objetivo": "...", "metodo_calculo": "...", "fonte_dados": "..." },
"competencias_disponiveis": [202412, 202512, 202601, 202602],
"ativo": true,
"serie_br_synced_at": "2026-06-03T09:00:05Z",
"municipio_synced_at": "2026-06-03T09:01:10Z"
}2. indicador_sage_serie_br — série nacional
Evolução anual nacional (resultset tipo=BR). Tabela minúscula. Sem coluna de "parcial": derivável de anomes (mês ≠ 12) + periodicidade_carga do catálogo.
| Campo | Tipo | Significado |
|---|---|---|
codigo | text FK | → indicadores_sage(codigo) |
anomes | integer | Competência AAAAMM (ex. 202512). |
valor | numeric | Valor nacional (contagem ou R$). |
// Exemplo (CEOVLR — financeiro; note o ponto parcial 202602)
[
{ "codigo": "CEOVLR", "anomes": 202212, "valor": 226805471 },
{ "codigo": "CEOVLR", "anomes": 202312, "valor": 282328339 },
{ "codigo": "CEOVLR", "anomes": 202412, "valor": 502460676 },
{ "codigo": "CEOVLR", "anomes": 202512, "valor": 506377601 },
{ "codigo": "CEOVLR", "anomes": 202602, "valor": 89421018 }
]3. indicador_sage_valor_municipio — mapa municipal
Valores por município (resultset tipo=MN), histórico completo. A API devolve várias cargas por município (mesmo codigogeo em competências diferentes) — persistimos todas; o "mapa atual" sai da view.
| Campo | Tipo | Significado |
|---|---|---|
codigo | text FK | → indicadores_sage(codigo) |
anomes | integer | Competência AAAAMM. |
codigogeo | integer | Código IBGE do município, 6 dígitos (sem DV). Casa com GeoJSONs do Brasil. |
uf / regiao / local | char(2) / text / text | Vêm preenchidos pela API (sem join auxiliar). |
valor | numeric | Valor observado no município. |
// Exemplo (CEOIMP — repare o mesmo município em 202601 e 202602)
[
{ "codigo": "CEOIMP", "anomes": 202601, "codigogeo": 120001, "uf": "AC", "regiao": "Norte", "local": "Acrelândia", "valor": 0 },
{ "codigo": "CEOIMP", "anomes": 202602, "codigogeo": 120001, "uf": "AC", "regiao": "Norte", "local": "Acrelândia", "valor": 0 },
{ "codigo": "CEOIMP", "anomes": 202601, "codigogeo": 120005, "uf": "AC", "regiao": "Norte", "local": "Assis Brasil", "valor": 0 },
{ "codigo": "CEOIMP", "anomes": 202602, "codigogeo": 355030, "uf": "SP", "regiao": "Sudeste", "local": "São Paulo", "valor": 24 }
]4. item_indicador_sage — vínculo N:N
Liga um indicador a um item estratégico (tipicamente um Resultado Esperado ou Ação). Guarda a proveniência do vínculo (a "árvore de vinculação" do CSV).
| Campo | Tipo | Significado |
|---|---|---|
id | uuid PK | Identidade da linha de vínculo. |
item_id | uuid FK | → itens_estrategicos(id) (qualquer tipo). |
codigo | text FK | → indicadores_sage(codigo). |
arvore_vinculacao | text | Token cru do CSV, ex. 'AE 6.2 RE 6.2.2'. |
numero_composto | text | Número composto resolvido, ex. '6.2.2'. |
criado_por / data_criacao | uuid / timestamptz | Auditoria. |
// Exemplo de linha
{
"id": "8f3a1c2e-...-uuid",
"item_id": "c12d77ab-...-uuid",
"codigo": "CEOIMP",
"arvore_vinculacao": "AE 6.2 RE 6.2.2",
"numero_composto": "6.2.2",
"criado_por": "a91b...-uuid",
"data_criacao": "2026-06-03T09:05:00Z"
}view vw_indicador_sage_mapa_atual
Último valor por município (max anomes por codigogeo), por indicador. Resolve a pegadinha do resultset MN trazer várias cargas — o front consome a view e renderiza o mapa direto, sem agrupar.
create view public.vw_indicador_sage_mapa_atual as
select distinct on (codigo, codigogeo)
codigo, codigogeo, uf, regiao, local, anomes, valor
from public.indicador_sage_valor_municipio
order by codigo, codigogeo, anomes desc;// Saída (1 linha por município — só a competência mais recente)
[
{ "codigo": "CEOIMP", "codigogeo": 120001, "uf": "AC", "local": "Acrelândia", "anomes": 202602, "valor": 0 },
{ "codigo": "CEOIMP", "codigogeo": 355030, "uf": "SP", "local": "São Paulo", "anomes": 202602, "valor": 24 }
]DDL completo
As tabelas, view, índices, GRANTs e políticas RLS vivem na migration supabase/migrations/20260608150050_create_indicadores_sage_schema.sql (imutável, já aplicável). Núcleo dos CREATE TABLE (idêntico ao migrado):
create table public.indicadores_sage (
codigo text primary key,
titulo text not null,
titulo_completo text,
unidade text, -- 'Número' | 'Moeda'
periodicidade_carga text not null default 'anual', -- 'mensal' | 'anual'
fonte text not null default 'SAGE/MGDI',
secretaria text,
tema text,
ficha jsonb not null default '{}',
metadado_raw jsonb not null default '{}',
competencias_disponiveis jsonb not null default '[]',
ativo boolean not null default true,
metadado_synced_at timestamptz,
serie_br_synced_at timestamptz,
municipio_synced_at timestamptz,
ficha_synced_at timestamptz,
data_criacao timestamptz not null default now(),
data_atualizacao timestamptz not null default now()
);
create table public.indicador_sage_serie_br (
codigo text not null references public.indicadores_sage(codigo) on delete cascade,
anomes integer not null,
valor numeric,
primary key (codigo, anomes)
);
create table public.indicador_sage_valor_municipio (
codigo text not null references public.indicadores_sage(codigo) on delete cascade,
anomes integer not null,
codigogeo integer not null,
uf char(2),
regiao text,
local text,
valor numeric,
primary key (codigo, anomes, codigogeo)
);
create table public.item_indicador_sage (
id uuid primary key default gen_random_uuid(),
item_id uuid not null references public.itens_estrategicos(id) on delete cascade,
codigo text not null references public.indicadores_sage(codigo) on delete cascade,
arvore_vinculacao text,
numero_composto text,
criado_por uuid references auth.users(id),
data_criacao timestamptz not null default now(),
unique (item_id, codigo)
);
-- + view, índices, GRANTs e RLS na migrationFonte: supabase/migrations/20260608150050_create_indicadores_sage_schema.sql · docs-indicadores/modelagem-banco-indicadores-sage.md
Design do Card
Para a equipe de design: o shape composto de um card de indicador, montado a partir do banco/cache (leitura VPN-independente), e de onde cada parte vem no 🗄️ Schema. A estratégia de componentes (EUI/Elastic Charts) está em 🎨 Estratégia de Frontend.
4 elementos → origem no schema
Cada card no painel precisa de 4 elementos: título descritivo, gráfico de barras (evolução anual BR), mapa por município (escala de cor gradiente) e ícone (i) (ficha).
| Parte do card | Origem no schema |
|---|---|
| Título / subtítulo do card | Dicionário de front (/lib/sage/titulos.ts) — não vem do banco |
| Formatação (R$ vs nº) | indicadores_sage.unidade |
| Gráfico de barras (evolução BR) | indicador_sage_serie_br + flag parcial derivada |
| Mapa coroplético | vw_indicador_sage_mapa_atual |
| Ícone (i) / ficha | indicadores_sage.ficha |
Shape do payload
{
"codigo": "CEOIMP",
"card": {
"titulo": "CEO cofinanciados",
"subtitulo": "Nº de Centros de Especialidades Odontológicas",
"unidade": "Número",
"formato": "inteiro"
},
"evolucaoBR": [
{ "anomes": 202212, "ano": 2022, "valor": 1098, "parcial": false },
{ "anomes": 202312, "ano": 2023, "valor": 1142, "parcial": false },
{ "anomes": 202412, "ano": 2024, "valor": 1175, "parcial": false },
{ "anomes": 202512, "ano": 2025, "valor": 1209, "parcial": false },
{ "anomes": 202602, "ano": 2026, "valor": 1175, "parcial": true }
],
"mapa": {
"competencia": 202602,
"municipios": [
{ "codigogeo": 120001, "uf": "AC", "local": "Acrelândia", "valor": 0 },
{ "codigogeo": 355030, "uf": "SP", "local": "São Paulo", "valor": 24 }
]
},
"fichaUrl": "/api/sage/indicadores/CEOIMP/ficha"
}Indicadores financeiros (unidade: "Moeda" → CEOVLR, SBRCUOM, SBVRFAF) têm carga mensal e trazem o ano corrente parcial (ex. 202602 = só jan+fev/2026). A barra marcada "parcial": true deve ser renderizada listrada/hachurada com rótulo "fev/2026" (réplica do mockup da SAGE) — plotar direto mostraria uma falsa queda de ~82%.
O front consome vw_indicador_sage_mapa_atual — já vem 1 valor por município (competência mais recente). Não precisa agrupar por codigogeo no cliente. Escala de cor gradiente sobre valor; join com GeoJSON por codigogeo (IBGE 6 díg).
Guia de Implementação
A camada de ingestão já está construída. Esta seção descreve como o sync e o vínculo funcionam de fato — não é mais uma receita a escrever. A UI dos cards (o que ainda falta) tem sua estratégia em 🎨 Estratégia de Frontend.
A API MGDI (apisix.demas.saude.gov/api-dev) só resolve dentro da VPN interna do MS, e Vercel/Supabase cloud não estão na VPN. Um teste real mostrou que o APISIX devolve CORS aberto (ecoa o Origin + Vary: Origin). Logo: o fetch roda no navegador do administrador (que está na VPN). Os dados parseados são enviados a server actions que persistem no Postgres via service_role. Não há túnel cloud→VPN, nem edge function, nem cron. Depois de persistido, todas as leituras do SIMPE são rápidas e independem da VPN.
Fluxo de ponta a ponta
Navegador do admin (na VPN) Supabase (Postgres)
┌──────────────────────────────┐ ┌───────────────────────────┐
│ lib/sage/mgdi-client.ts │ │ indicadores_sage │
│ fetch 5 endpoints (CORS open)│ payload │ indicador_sage_serie_br │
│ parse + chunk (MN) │ ───────► │ indicador_sage_valor_ │
│ │ │ server │ municipio │
│ ▼ │ actions │ item_indicador_sage │
│ actions/indicadores-sage/* │ ───────► │ (via service_role, │
│ (re-checa papel Admin) │ RPC │ upsert/replace) │
└──────────────────────────────┘ └───────────────────────────┘
│
leitura (cache/admin, VPN-independente) ▼
SIMPE front renderiza os cardsOnde fica
A página é a aba Painel Admin → Indicadores SAGE (app/(main)/painel-admin/indicadores-sage/page.tsx) — Admin/SuperAdmin apenas (checado na server action e em RLS, defesa em profundidade). Dois painéis:
- Sincronizar (
IndicadoresSageSyncPanel) — puxa os 5 datasets da MGDI. - Vincular (
VincularIndicadorPanel) — liga indicadores a itens do plano.
1. Sincronização (browser → server action → RPC)
O lib/sage/mgdi-client.ts roda no navegador (na VPN), busca e parseia cada endpoint, e chama as server actions de actions/indicadores-sage/sync.ts. Cada dataset persiste com a estratégia adequada e carimba seu próprio *_synced_at — freshness honesta mesmo em falha parcial.
| Endpoint MGDI | Persiste em | Estratégia / RPC |
|---|---|---|
GET /indicador/{c} (metadado) | indicadores_sage | upsert em codigo — upsert-metadado.ts |
GET /indicador/{c}/carga | indicadores_sage.competencias_disponiveis | update — upsert-carga.ts |
GET /indicador/{c}/resultset?tipo=BR | indicador_sage_serie_br | delete-then-insert por código — replace-serie-br.ts |
GET /indicador/{c}/resultset?tipo=MN | indicador_sage_valor_municipio | upsert por chunk em (codigo, anomes, codigogeo) — upsert-municipio-chunk.ts |
GET /ficha-qualificacao/{c} | indicadores_sage.ficha | update — upsert-ficha.ts |
O resultset MN traz ~1.1 MB / ~11k linhas por código. O client fatia em chunks (~2000 linhas, body < 1 MB) e o municipio_synced_at só é carimbado no último chunk — se o upload cair no meio, a freshness não mente. A pegadinha das "múltiplas cargas por município" é resolvida no banco pela view vw_indicador_sage_mapa_atual, não no app.
2. Vínculo indicador ↔ item estratégico
O VincularIndicadorPanel associa um indicador a um item do plano (tipicamente Resultado ou Ação), criando uma linha em item_indicador_sage. Dois caminhos:
- Auto-sugestão pela "árvore de vinculação" do CSV: token
'AE 6.2 RE 6.2.2'→ número composto'6.2.2'→ casa contra as colunasnumerodenormalizadas dos*_dashboard_cache(escopo por plano) →item_id(resolve-arvore-vinculacao.ts) → o admin confirma em 1 clique. - Picker manual: cascata Objetivo → Ação → Resultado (
IndicadorHierarchyPicker) quando não há sugestão ou se quer ajustar.
A proveniência (arvore_vinculacao cru + numero_composto resolvido + criado_por) fica gravada na linha para auditoria. O parser do número composto é coberto por testes (__tests__/rpc/indicadores-sage/resolve-arvore-vinculacao.test.ts).
3. Seed — dado real, sem VPN no dev local
Para desenvolver fora da VPN, o dado real da MGDI está commitado em seeds divididos:
supabase/seeds/indicadores-sage.sql— catálogo (7 códigos do piloto) + série BR + vínculos.supabase/seeds/indicadores-sage-municipio.sql— mapa municipal (~4 MB, viaCOPY).
Gerados por scripts/gen-indicadores-sage-seed.mjs e referenciados em supabase/config.toml (sql_paths). Num supabase db reset o banco local já nasce populado.
Na nuvem você só faz push da migration — sem seed. O upsert-metadado faz insert ... on conflict (codigo), então a primeira sync rodada por um admin na VPN cria as linhas do catálogo e preenche o resto. Banco cloud vazio se semeia sozinho no primeiro sync.
4. Leitura (o que a UI vai consumir)
Os cards leem do Postgres (cache/admin client), nunca da MGDI — por isso a leitura é VPN-independente. As helpers de tratamento já existem; ex. o ano corrente parcial dos financeiros é derivável do grão sem coluna extra:
// Marca o ponto parcial: mês ≠ 12 no ano corrente, em indicador mensal.
// `anomes` (AAAAMM) + `periodicidade_carga='mensal'` bastam — sem coluna "parcial".
export function isPontoParcial(anomes: number, anoCorrente: number): boolean {
const ano = Math.floor(anomes / 100);
const mes = anomes % 100;
return ano === anoCorrente && mes !== 12;
}Estratégia de Frontend
Recomendação para construir os cards de indicador com paridade visual e de UX com o stack Elastic/Kibana — Elastic UI (EUI) + Elastic Charts. Esta seção é estratégia: não há código de front implementado ainda.
Os mockups da SAGE espelham a linguagem visual do Kibana (cards densos, gráficos de série temporal, mapas coropléticos). Reproduzir isso com EUI + Elastic Charts dá paridade "de graça" — em vez de recriar o look do Kibana sobre shadcn/Tailwind, usamos a mesma biblioteca que o origina.
Realidade de compatibilidade (verificado jun/2026)
O SIMPE roda Next.js 16.1.6, React 19.2.0, react-dom 19.2.0, Tailwind 4, Radix/shadcn; hoje sem Emotion, com gráficos via Recharts 3.7.0 e sem lib de mapa. Já o @elastic/eui mais recente (116.3.1) declara peerDependencies react: ^17 || ^18 (base Emotion 11) e o @elastic/charts (71.7.0) declara react: ^16.12 || ^17 || ^18. Nenhum dos dois lista React 19. O épico de suporte a React 19 (elastic/eui#8720) segue sem entregar em jun/2026 (bloqueado por refatorações de componentes legados incompatíveis com <StrictMode>). Premissa de versão: tratamos EUI/Charts como "React 18-target rodando sob React 19 com peer forçado", a validar componente a componente — não como suporte oficial.
Como introduzir no stack atual — honestamente
- (a) Mismatch de peer com React 19. Instalar com
overridesnopackage.json(ou--legacy-peer-deps) para destravar o peer, e validar os componentes específicos que usarmos — não assumir o pacote inteiro. Evitar montar a árvore EUI sob<StrictMode>(double-invoke quebra os componentes legados citados no épico). Travar versões exatas e cobrir os cards com smoke tests. - (b) Emotion/CSS-in-JS + SSR. EUI usa Emotion e exige
<EuiProvider>. No App Router isso pede um cache Emotion próprio conectado viauseServerInsertedHTML(denext/navigation) para o CSS sair no SSR sem flash — tudo atrás de fronteiras"use client"(EUI é client-only). - (c) Escopo restrito às superfícies de indicador. Montar EUI só numa ilha (subárvore de rota dos indicadores) com seu próprio cache + provider, para não brigar com o theming Tailwind/shadcn no resto do app. Conter/resetar estilos na fronteira da ilha (Tailwind preflight × reset do EUI). O dark/light do EUI (
EuiProvider colorMode) deve seguir o tema já existente do SIMPE.
# Premissa: peer forçado até o suporte oficial a React 19 sair.
npm i @elastic/eui @elastic/eui-theme-borealis @elastic/charts \
@emotion/react @emotion/css moment @elastic/datemath
# package.json → "overrides": { "react": "19.2.0", "react-dom": "19.2.0" }
# Escopar a uma ilha "use client" com EuiProvider + cache Emotion próprio.4 elementos do card → componente Elastic + fonte de dado
| Elemento | Componente Elastic | Fonte de dado |
|---|---|---|
| (1) Título | EuiTitle / EuiText dentro de EuiPanel | Dicionário curado lib/sage/titulos.ts (a criar) — lê o catálogo (metadado). Nunca exibir titulo/titulo_completo crus (inconsistentes — ver Ressalva #3). |
| (2) Evolução BR | Elastic Charts: Chart + LineSeries/BarSeries + Axis | indicador_sage_serie_br (mensal). Marcar o ponto do ano corrente parcial nos "Moeda"/periodicidade_carga='mensal' (anomes mês ≠ 12 ⇒ parcial) com estilo distinto (hachura/rótulo "fev/2026") — ver Ressalva #1. |
| (3) Mapa municipal | Coroplético leve (ver recomendação abaixo) com paleta EUI | vw_indicador_sage_mapa_atual + GeoJSON municipal do Brasil, join por codigogeo (IBGE 6 díg, sem DV). View já desduplica. |
| (4) Ficha (i) | EuiButtonIcon → EuiPopover/EuiFlyout | indicadores_sage.ficha (jsonb). |
Mapa: por que NÃO o Elastic Maps
O Elastic Maps é um plugin do Kibana acoplado a um backend Elasticsearch — não um componente React standalone embutível num card do SIMPE. Recomenda-se um renderizador coroplético leve (estilo react-simple-maps / SVG+topojson com d3-geo), estilizado com a paleta sequencial do EUI para manter a paridade visual. Justificativa: (1) bundle muito menor; (2) sem dependência de ES — lê direto a view/GeoJSON; (3) embutível dentro do EuiPanel do card. O join é por codigogeo (6 dígitos), que já casa com os GeoJSONs municipais do IBGE. Para a legenda/escala, reusar euiPaletteForSequence() dá cor coerente com os gráficos.
Rollout em fases
- Prova de conceito (1 card): título + gráfico de evolução (Elastic Charts) numa ilha
"use client"comEuiProvider, validando EUI/Charts sob React 19 (peer forçado, sem StrictMode). Mede risco real antes de investir. - Um indicador completo: adicionar mapa coroplético + ficha (i) ao card de PoC, fechando os 4 elementos para 1 código (ex.
CEOIMP). - Galeria: replicar para os 7 do piloto, montar o dicionário
lib/sage/titulos.tse a grade de cards.
O dado já vive persistido nas tabelas/view (🗄️ Schema), populado pelo sync da 💻 Implementação. A UI lê pelo caminho normal de leitura (cache/admin client) — nunca chama a MGDI. Logo, a renderização dos cards é VPN-independente: a ilha EUI é puramente de apresentação sobre dados já no Postgres.
Ressalvas
Três comportamentos da API que precisam ser tratados no front. Ignorá-los gera bugs silenciosos — gráficos renderizam, mas com valor errado.
#1 — Ano corrente parcial nos indicadores financeiros
Afeta: CEOVLR, SBRCUOM, SBVRFAF (mockups 4.1, 4.2, 4.4).
A API tem cargas mensais pra esses 3 indicadores e cargas anuais pros outros 4. O parâmetro anodata=-5 retorna a competência mais recente de cada ano calendário, então pros financeiros vem 202602 (fev/2026, parcial) misturado com 202512, 202412, etc. (anos fechados em dezembro).
Exemplo real do CEOVLR (de _source/samples/resultset-br-ceovlr.json):
| anomes | Valor (R$) | Status |
|---|---|---|
| 202212 | 226.805.471 | ano fechado |
| 202312 | 282.328.339 | ano fechado |
| 202412 | 502.460.676 | ano fechado |
| 202512 | 506.377.601 | ano fechado |
| 202602 | 89.421.018 | apenas jan+fev/2026 — PARCIAL |
Plotar direto vai mostrar 2026 como queda de 82%, o que é factualmente errado.
Solução técnica: derivar o flag de parcial do grão (isPontoParcial(), Implementação §4) — não filtra, só marca o ponto pro componente de gráfico aplicar tratamento visual (rótulo "fev/2026", padrão listrado, etc.). Estilização do ponto em 🎨 Estratégia de Frontend.
Decisão visual pendente com Luísa — ver ✅ Pendências.
#2 — Resultset MN retorna múltiplas competências por município
O parâmetro anodata=-1 no endpoint de mapa não retorna 1 ponto por município: retorna todas as cargas do último ano calendário. Em janeiro/2026, vieram 202601 E 202602 pro mesmo município de Acrelândia. Isso explica o tamanho do response (1.1 MB pros não-financeiros, 562 KB pros financeiros).
Exemplo real (de _source/samples/resultset-mn-trecho.json):
[
{ "anomes": 202601, "uf": "AC", "local": "Acrelândia", "codigogeo": 120001, "CEOIMP": 0 },
{ "anomes": 202602, "uf": "AC", "local": "Acrelândia", "codigogeo": 120001, "CEOIMP": 0 }
]Sem agrupamento, um data.find(m => m.codigogeo === 120001) ingênuo vai pegar a primeira ocorrência (geralmente janeiro), não a mais recente. Bug silencioso clássico — o gráfico renderiza, mas com valor errado.
Solução técnica: agrupar por codigogeo mantendo o item com anomes máximo, e o cache deve guardar já a versão agrupada (não o response cru) — senão cada render do front faz o agrupamento de novo, desperdiçando CPU.
function ultimaPorMunicipio<C extends string>(rs: ResultsetMN<C>): ResultsetMN<C> {
const acc = new Map<number, ResultsetMN<C>[number]>();
for (const row of rs) {
const atual = acc.get(row.codigogeo);
if (!atual || row.anomes > atual.anomes) acc.set(row.codigogeo, row);
}
return Array.from(acc.values());
}#3 — titulo × tituloCompleto inconsistentes
Após inspeção real da API (27/04/2026), confirmou-se que os campos titulo e tituloCompleto não seguem padrão consistente entre indicadores. Em alguns o titulo é a versão informativa e o tituloCompleto é uma definição técnica longa. Em outros é o oposto. E o SBESB chega a contradizer o próprio campo curto.
Comparação real dos 7 indicadores SAPS (de _source/01-cobertura-api-simpe-saude-bucal.md):
| Código | titulo | tituloCompleto |
|---|---|---|
CEOIMP | Número de Centros de Especialidades Odontológicas implantado e custeado | Número de Centros de Especialidades Odontológicas pago no período |
CEOVLR | Valor repassado para custeio de Centros de Especialidades Odontológicas - CEO | Quantidade de recursos (em real) do tipo custeio destinado aos municípios, pelo Ministério da Saúde, para o cofinanciamento dos Centros de Especialidades Odontológicas CEO. |
SBESB | Número de Equipes de Saúde Bucal (40h + carga horária diferenciada) | Número de Equipes de Saúde Bucal – carga horária de 20, 30 e 40 horas ⚠️ |
SBESB40H | Número de Equipes de Saúde Bucal 40h | Número de Equipes de Saúde Bucal 40h custeadas pelo MS |
SBRCUOM | Valor repassado para custeio UOM | Valor recurso de custeio destinado ao cofinanciamento das Unidades Odontológicas Móveis (UOM) |
SBUOMP | Número de Unidades Odontológicas Móveis - UOM pagas | Número de Unidades Odontológicas Móveis - UOM pagas (idêntico) |
SBVRFAF | Valor repassado para custeio das Equipes de Saúde Bucal | Valor repassado referente ao custeio das Equipes de Saúde Bucal no mês de referência |
Solução recomendada: não usar nenhum dos dois campos como fonte direta dos títulos do mockup. Mariana monta um dicionário de títulos por código MGDI alinhado com os títulos dos mockups. O front busca primeiro no dicionário (lib/sage/titulos.ts) e usa tituloCompleto como fallback. Ver 🎨 Estratégia de Frontend.
Pendências
Itens acionáveis até o piloto rodar em produção. Bloqueadores travam o lançamento; não-bloqueadores afetam qualidade ou completude visual.
- Schema migrado (4 tabelas + view, GRANTs + RLS) —
20260608150050_create_indicadores_sage_schema.sql. - Mecanismo de sync — antes uma pendência aberta (edge function/cron com acesso VPN); resolvido por sync client-side no navegador do admin (APISIX com CORS aberto) → server actions → upsert via
service_role. Sem túnel, sem edge function, sem cron. - Aba Painel Admin → Indicadores SAGE com painéis de sync e de vínculo (auto-sugestão da árvore + picker manual).
- Vínculo N:N indicador↔item com proveniência; seeds de dado real para dev local sem VPN.
Bloqueadores do piloto
-
8 indicadores sem código MGDI
-
Dados não carregados nos novos códigos de Saúde Bucal (lote 26/05/2026)
-
Endpoint quebrado:
MGDI_MS_XAW(lote 22/06/2026)
Não-bloqueadores
-
Construir a UI dos cards de indicador
-
Sync de produção depende de admin na VPN
-
Corrigir códigos e rótulos no CSV de Saúde Bucal
-
Tratamento visual do ano corrente parcial
-
Validar mapeamento "Carga Horária Diferenciada" =
SBESB - SBESB40H -
Construir dicionário de títulos por código MGDI
-
Confirmar política de rate-limit no APISIX
-
Investigar gap de carga
202410no CEOVLR